CHART_COLORS = [
'#020A3E', // Primary Navy
'#0D83FF', // Secondary Blue
'#47B5FF', // Light Blue
'#94A3B8', // Slate 400
'#CBD5E1', // Slate 300
'#F1F5F9' // Slate 100
];
// 1. LOAD DATA & HYDRATE STRINGS
lookup_raw = FileAttachment("string_lookup.csv").csv({typed: true})
lookup = new Map(lookup_raw.map(d => [d.id, d.text]))
data_raw = FileAttachment("inman_survey_cube_final.csv").csv({typed: true})
data = data_raw.map(d => {
const dt = new Date(d.Date);
dt.setUTCDate(20);
return {
...d,
Date: dt,
Region: lookup.get(d.Region),
Brokerage: lookup.get(d.Brokerage),
Volume: lookup.get(d.Volume),
Question: lookup.get(d.Question),
Response: lookup.get(d.Response),
Track: lookup.get(d.Track)
};
})
// 2. STATE
mutable selectedQuestion = null
// 3. TRACK SELECTOR
viewof selectedTrack = {
const container = html`<div class="track-toggle">
<button class="track-btn active" value="Agents">AGENTS</button>
<button class="track-btn" value="Brokerage Leaders">LEADERS</button>
</div>`;
const buttons = container.querySelectorAll("button");
buttons.forEach(btn => {
btn.onclick = (e) => {
buttons.forEach(b => { b.classList.remove("active"); });
btn.classList.add("active");
container.value = btn.value;
container.dispatchEvent(new CustomEvent("input"));
};
});
container.value = "Agents";
return container;
}
// Reset filters when track changes
{
selectedTrack;
viewof regionFilter.value = "All";
viewof regionFilter.dispatchEvent(new CustomEvent("input"));
viewof brokerageFilter.value = "All";
viewof brokerageFilter.dispatchEvent(new CustomEvent("input"));
viewof volumeFilter.value = "All";
viewof volumeFilter.dispatchEvent(new CustomEvent("input"));
}
viewof regionFilter = Inputs.select(["All", "West", "Midwest", "South", "Northeast"], {label: null, value: "All"})
viewof brokerageFilter = Inputs.select(["All", "Franchising", "Private Indie", "Big Non-Franchising"], {label: null, value: "All"})
viewof volumeFilter = Inputs.select(["All", "High Volume", "Lower Volume"], {label: null, value: "All", disabled: selectedTrack !== "Agents"})
// Handle exclusivity: when one filter changes, reset others to "All"
{
regionFilter;
if (regionFilter !== "All") {
viewof brokerageFilter.value = "All";
viewof brokerageFilter.dispatchEvent(new CustomEvent("input"));
viewof volumeFilter.value = "All";
viewof volumeFilter.dispatchEvent(new CustomEvent("input"));
}
}
questionData = filteredCube.filter(d => d.Question === selectedQuestion)
chartData = {
const grouped = d3.rollup(questionData, v => d3.sum(v, d => d.Count), d => d.Date, d => d.Response);
// Calculate True N for each date in questionData by summing Total_N across unique slicer combinations
const dateTotals = d3.rollup(questionData,
v => {
const slices = d3.groups(v, d => `${d.Region}|${d.Brokerage}|${d.Volume}`);
return d3.sum(slices, s => s[1][0].Total_N);
},
d => d.Date
);
const flat = [];
for (const [date, responses] of grouped) {
const totalN = dateTotals.get(date) || 1;
for (const [resp, count] of responses) {
flat.push({
Date: date,
Response: resp,
Count: count,
Percentage: count / totalN,
Total_N: totalN
});
}
}
return flat.sort((a, b) => a.Date - b.Date);
}
snapshotData = chartData.filter(d => d.Date.getTime() === (dateFilter ? dateFilter.getTime() : 0))
// 11. CHARTS
// Helper to get available width in the main area (subtract padding)
mainWidth = {
const isMobile = width < 768;
const sidebarW = isMobile ? 0 : 340;
const mainPadding = isMobile ? 48 : 64; // p-6 (48px) vs p-8 (64px)
const cardPadding = 48; // p-6 on cards
return width - sidebarW - mainPadding - cardPadding;
}
wrap = (text, width = 35) => {
if (text === null || text === undefined) return "";
const str = String(text);
const words = str.split(/\s+/);
let line = [], length = 0, lines = [];
for (const word of words) {
if (length + word.length > width) { lines.push(line.join(" ")); line = []; length = 0; }
line.push(word); length += word.length + 1;
}
if (line.length) lines.push(line.join(" "));
return lines.join("\n");
}
// Interactive Trend Legend Selection
allResponses = Array.from(new Set(chartData.map(d => d.Response)))
viewof trendSelection = {
// Reset selection when the question changes
selectedQuestion;
const container = html`<div class="flex flex-wrap gap-x-5 gap-y-2"></div>`;
const selected = new Set(allResponses);
const render = () => {
container.innerHTML = "";
allResponses.forEach((resp, i) => {
const isSelected = selected.has(resp);
const color = CHART_COLORS[i % CHART_COLORS.length];
const btn = html`
<div class="flex items-center cursor-pointer transition-all hover:translate-y-[-1px] ${isSelected ? 'opacity-100' : 'opacity-25'}" style="gap: 8px;">
<div style="width: 14px; height: 14px; border-radius: 3px; background-color: ${color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1)"></div>
<span class="text-[0.8rem] font-medium text-slate-600 tracking-tight select-none">${resp}</span>
</div>`;
btn.onclick = () => {
if (selected.has(resp)) {
if (selected.size > 1) selected.delete(resp);
} else {
selected.add(resp);
}
render();
container.value = Array.from(selected);
container.dispatchEvent(new CustomEvent("input"));
};
container.append(btn);
});
};
render();
container.value = Array.from(selected);
return container;
}
trendDataFiltered = chartData.filter(d => trendSelection.includes(d.Response))
snapshotPlot = {
const isMobile = width < 768;
const labelWrap = isMobile ? 25 : 35;
const marginL = isMobile ? 120 : 180;
return Plot.plot({
width: mainWidth,
marginLeft: marginL,
height: Math.max(400, d3.sum(snapshotData, d => wrap(d.Response, labelWrap).split("\n").length) * 14 + (snapshotData.length * 10)),
color: {range: CHART_COLORS, domain: allResponses},
x: {label: "Percentage", tickFormat: "%", domain: [0, 1], grid: true},
y: {
label: null,
domain: snapshotData.sort((a,b) => b.Percentage - a.Percentage).map(d => d.Response),
tickFormat: d => wrap(d, labelWrap),
padding: 0.2
},
marks: [
Plot.barX(snapshotData, {
x: "Percentage",
y: "Response",
fill: "Response",
tip: true,
title: d => `${d.Response}\n${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.text(snapshotData, {
x: "Percentage",
y: "Response",
text: d => `${(d.Percentage * 100).toFixed(0)}%`,
dx: 5,
textAnchor: "start",
fontWeight: "bold",
fill: "#020A3E"
})
]
})
}
trendPlot = Plot.plot({
width: mainWidth,
marginLeft: 50, height: 400,
color: {range: CHART_COLORS, domain: allResponses},
x: {
label: null,
ticks: width < 600 ? 5 : "month",
tickFormat: d => d.toLocaleDateString('en-US', {month: 'short', year: width < 600 ? '2-digit' : 'numeric', timeZone: 'UTC'})
},
y: {label: "Share", tickFormat: "%", grid: true},
marks: [
Plot.areaY(trendDataFiltered, {
x: "Date",
y: "Percentage",
fill: "Response",
order: "sum",
curve: "monotone-x",
tip: true,
title: d => `${d.Date.toLocaleDateString('en-US', {day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC'})}\n${d.Response}: ${(d.Percentage * 100).toFixed(1)}%`
}),
Plot.ruleY([0])
]
})
{
const sCont = document.getElementById('snapshot-chart-container');
const tCont = document.getElementById('trend-chart-container');
const tLegendCont = document.getElementById('trend-legend-container');
const trendCard = document.getElementById('trend-card');
const nDisplay = document.getElementById('sample-size-display');
if (sCont) { sCont.innerHTML = ""; sCont.append(snapshotPlot); }
if (nDisplay) {
const totalN = snapshotData.length > 0 ? snapshotData[0].Total_N : 0;
nDisplay.innerText = `N = ${totalN.toLocaleString()}`;
// Visual warning if N is low (less than 10)
if (totalN < 10) {
nDisplay.classList.replace('bg-blue-50', 'bg-amber-50');
nDisplay.classList.replace('text-brand-secondary', 'text-amber-600');
} else {
nDisplay.classList.replace('bg-amber-50', 'bg-blue-50');
nDisplay.classList.replace('text-amber-600', 'text-brand-secondary');
}
}
if (tCont && trendCard) {
const q = selectedQuestion;
const isRecurring = questionsPrev && questionsPrev.has(q);
const uniqueMonths = new Set(chartData.map(d => d.Date.getTime())).size;
if (isRecurring && uniqueMonths > 1) {
trendCard.style.display = "block";
if (tLegendCont) { tLegendCont.innerHTML = ""; tLegendCont.append(viewof trendSelection); }
tCont.innerHTML = "";
tCont.append(trendPlot);
} else {
trendCard.style.display = "none";
}
}
}